Перейти к основному содержимому

5.09. Операторы

Разработчику Архитектору

Операторы в Kotlin

В языке программирования Kotlin операторы являются синтаксическими конструкциями, предназначенными для выполнения преобразований над значениями, управления потоком вычислений и обеспечения удобства взаимодействия с типами данных. В отличие от некоторых других языков, Kotlin не просто заимствует операторы, но и значительно расширяет их семантику за счёт встроенной поддержки null-безопасности, инфиксной записи, перегрузки и тесной интеграции с системой типов. Это делает Kotlin одновременно выразительным и строгим инструментом для написания надёжного кода.

Классификация операторов в Kotlin условно разделяется на несколько групп по функциональному назначению: арифметические, логические, операторы сравнения, операторы присваивания, а также специализированные операторы, поддерживающие работу с nullable-типами. Важно отметить, что большинство операторов в Kotlin реализовано как вызовы соответствующих функций-членов классов. Это позволяет разработчикам перегружать операторы в собственных типах, сохраняя при этом предсказуемую и единообразную семантику.

Арифметические операторы

Арифметические операторы в Kotlin предназначены для выполнения базовых математических вычислений над числовыми типами — целыми (Int, Long, Short, Byte) и дробными (Float, Double). Поддерживаемые операторы: + (сложение), - (вычитание), * (умножение), / (деление) и % (остаток от деления). Никаких особых синтаксических отличий от других C-подобных языков здесь нет, однако важно учитывать особенности поведения при работе с целыми и дробными числами.

Оператор деления / в Kotlin сохраняет семантику, принятую в Java: при применении к целочисленным операндам результат также будет целочисленным, с отбрасыванием дробной части. Так, выражение 7 / 2 даёт значение 3, а не 3.5. Для получения дробного результата хотя бы один из операндов должен иметь дробный тип, либо быть явно преобразован к таковому — например, 7.0 / 2 или 7.toDouble() / 2.

Оператор остатка % в Kotlin соответствует операции remainder, а не modulo. Это означает, что знак результата совпадает со знаком делимого. Например, -7 % 3 даёт -1, а не 2, что отличается от поведения классической математической операции модуля. Эта особенность унаследована от JVM и полностью совместима с поведением Java, что обеспечивает предсказуемость при интеграции с существующим Java-кодом.

Все арифметические операторы в Kotlin могут быть перегружены. Это достигается за счёт объявления функций с ключевым словом operator, например operator fun plus(other: MyType): MyType. При этом компилятор заменяет запись a + b на вызов a.plus(b). Такой подход позволяет использовать привычный синтаксис даже для пользовательских типов — например, для сложения комплексных чисел, конкатенации векторов или объединения множеств.

Стоит отметить, что Kotlin не поддерживает постфиксные и префиксные инкременты и декременты (++, --) в виде отдельных операторов в том виде, в котором они существуют в C или Java. Вместо них рекомендуется использовать явные операции присваивания, например i = i + 1, либо, при необходимости, объявлять кастомные функции типа inc() и dec(), которые могут быть вызваны через синтаксис i++ только в том случае, если соответствующие операторные функции определены.

Логические операторы

Логические операторы в Kotlin служат для построения составных условий и управления логикой ветвления. В языке поддерживаются три основных оператора: && (логическое И), || (логическое ИЛИ) и ! (логическое НЕ). Все они работают исключительно с операндами типа Boolean и не допускают неявного приведения других типов к булевому — это исключает потенциальные ошибки, связанные с интерпретацией ненулевых чисел как true, как это имеет место в некоторых динамических языках.

Операторы && и || реализуют short-circuit evaluation («ленивую» (отложенную) оценку): если значение левого операнда делает результат выражения однозначным, правый операнд не вычисляется. В случае a && b, если a равно false, вычисление b не происходит. Аналогично, в выражении a || b, если a равно true, вычисление b пропускается. Такое поведение повышает эффективность и позволяет безопасно строить составные условия, в которых правый операнд может зависеть от проверки в левой части — например, obj != null && obj.isValid().

Оператор ! унарный и инвертирует значение булевского выражения. Он имеет высший приоритет среди логических операторов, что делает запись !a && b эквивалентной (!a) && b, а не !(a && b). Это соответствует стандартному порядку операций и исключает необходимость в избыточных скобках в большинстве практических случаев.

Важно подчеркнуть, что в Kotlin отсутствует оператор побитового ИЛИ (|), побитового И (&) или исключающего ИЛИ (^) в контексте логических выражений. Для побитовых операций используются отдельные функции или операторы, применимые к целочисленным типам, но их использование в булевой логике сознательно ограничено — язык поощряет использование именно логических операторов для выражения условий, что улучшает читаемость и снижает риск ошибок, связанных с путаницей между побитовыми и логическими операциями.

Операторы сравнения

Операторы сравнения в Kotlin включают равенство (==, !=) и порядковые отношения (<, >, <=, >=). Их поведение в значительной степени определяется системой типов и концепцией структурного сравнения, заложенной в язык на уровне проектирования.

Оператор == в Kotlin не проверяет физическое равенство ссылок, как это делает == в Java. Вместо этого он вызывает функцию equals() у левого операнда, передавая правый операнд в качестве аргумента — то есть a == b транслируется в a?.equals(b) ?: (b === null). Такая семантика обеспечивает структурное равенство: два объекта считаются равными, если их содержимое совпадает, даже если они представляют собой разные экземпляры в памяти. Это соответствует ожиданиям большинства разработчиков и согласуется с практикой, принятой в функциональных языках.

Для проверки идентичности ссылок (физического равенства) в Kotlin используется оператор ===. Он возвращает true только в том случае, если оба операнда ссылаются на один и тот же объект в памяти — или оба равны null. Оператор !== является его отрицанием. Важно, что === и !== работают только с ссылочными типами и не применимы к примитивам напрямую — хотя на уровне JVM примитивы могут быть упакованы, и тогда проверка идентичности выполнится над объектами-обёртками.

Операторы <, >, <=, >= работают только с типами, реализующими интерфейс Comparable<T>. В стандартной библиотеке Kotlin все базовые числовые типы, строки, даты и многие коллекции реализуют этот интерфейс, что делает их сравнимыми «из коробки». При этом сравнение объектов, не реализующих Comparable, приведёт к ошибке компиляции — язык принципиально избегает неопределённого поведения. В отличие от Java, в Kotlin невозможно случайно сравнить несравнимые типы: компилятор строго проверяет совместимость операндов.

Как и арифметические, операторы сравнения могут быть перегружены. Для этого достаточно объявить операторную функцию compareTo(other: T): Int в классе, реализующем Comparable<T>. При этом выражение a < b транслируется в a.compareTo(b) < 0 и т.д. Это позволяет задавать кастомную логику упорядочения — например, сортировку по нескольким критериям или обратный порядок.


Отсутствие тернарного оператора и альтернативы

В отличие от многих C-подобных языков, Kotlin сознательно отказывается от традиционного тернарного оператора в форме условие ? значение_если_истина : значение_если_ложь. Такое решение мотивировано стремлением к унификации синтаксиса и повышению выразительности кода. В Kotlin выражение if является выражением, а не оператором, то есть оно всегда возвращает значение, и его результат может быть присвоен переменной, передан в функцию или использован в составе более сложного выражения.

Пример:

val max = if (a > b) a else b

Эта запись эквивалентна гипотетическому a > b ? a : b, и обладает рядом преимуществ. Во-первых, она не вводит отдельную, узкоспециализированную синтаксическую конструкцию — язык остаётся лаконичнее. Во-вторых, ветви if и else в Kotlin могут содержать не только простые выражения, но и блоки кода, что делает конструкцию гибкой без необходимости прибегать к обходным решениям. Например:

val description = if (score >= 90) {
println("Отличный результат!")
"Отлично"
} else if (score >= 75) {
"Хорошо"
} else {
"Требуется доработка"
}

Такой подход поддерживает как простые случаи, так и многоуровневую логику без деградации читаемости.

Кроме if-else, для некоторых частных случаев выбора значения по условию Kotlin предлагает более специализированные инструменты — в первую очередь, элвис-оператор (?:), который, хотя и напоминает тернарный оператор, имеет чётко ограниченную и предсказуемую область применения.


Элвис-оператор (?:)

Элвис-оператор, названный по аналогии с причёской Элвиса Пресли (из-за визуального сходства ?: с его бровями и улыбкой), является центральным элементом стратегии null-безопасности в Kotlin. Его назначение — предоставить значение по умолчанию в случае, если левый операнд равен null.

Синтаксис прост и выразителен:
выражение_с_nullable_типом ?: значение_по_умолчанию

При вычислении выражения с элвис-оператором происходит следующее: сначала вычисляется левый операнд. Если его значение не равно null, оно и становится результатом всего выражения, а правый операнд не вычисляется. Если же левый операнд равен null, вычисляется правый операнд, и его значение возвращается как результат.

Пример:

val name: String? = findNameInDatabase() // может вернуть null
val displayName = name ?: "Аноним"

Здесь displayName гарантированно будет иметь тип String (не nullable), поскольку в случае отсутствия имени будет использована запасная строка.

Важное свойство элвис-оператора — ленивая оценка правого операнда. Это означает, что выражение справа от ?: вычисляется только при необходимости. Это позволяет безопасно использовать дорогостоящие или побочные операции в качестве значения по умолчанию, не опасаясь их выполнения при наличии корректного значения слева:

val config = loadConfig() ?: {
logWarning("Конфигурация не найдена, используются настройки по умолчанию")
DefaultConfig()
}()

В данном примере лямбда-выражение (и, соответственно, логирование и создание объекта) будет выполнено только если loadConfig() вернуло null.

Элвис-оператор может быть составной — то есть, может применяться цепочкой:

val result = value1 ?: value2 ?: value3 ?: "fallback"

Такая запись эквивалентна последовательной проверке каждого значения на null и возврату первого ненулевого. Это особенно удобно при работе с иерархиями источников данных — например, сначала из кэша, затем из локального хранилища, затем из сети, и, наконец, значение по умолчанию.

Стоит подчеркнуть, что правый операнд элвис-оператора должен иметь тип, совместимый с ненулевой версией типа левого операнда. Например, если слева String?, то справа допустимы String, String? или любой подтип String (хотя в случае String? компилятор выдаст предупреждение, так как это противоречит цели оператора — устранению null). Компилятор строго следит за тем, чтобы результат выражения с ?: имел ненулевой тип, что исключает «просачивание» null без явного намерения.


Безопасный вызов (?.)

Оператор безопасного вызова (?.) решает фундаментальную проблему, известную как The Billion Dollar Mistake — частые ошибки, возникающие при обращении к полям или методам объекта, который оказался null. В Kotlin эта проблема устраняется на уровне синтаксиса и типовой системы.

Оператор ?. размещается между ссылкой на объект и последующим доступом к его члену (полю, свойству, методу). Его семантика такова: если объект слева от ?. не равен null, выполняется вызов справа и возвращается его результат. Если же объект равен null, весь вызов возвращает null — без выброса исключения и без выполнения правой части.

Пример:

val length: Int? = person?.address?.street?.length

Здесь каждая точка заменена на ?., что делает всю цепочку null-устойчивой. Если person равен null — результат будет null. Если person есть, но address внутри него null — также null. И так далее. В итоге, length будет иметь тип Int?, и программа не завершится аварийно ни на одном этапе.

Ключевая особенность: результат применения ?. всегда имеет nullable-тип, даже если вызываемый член возвращает ненулевой тип. То есть, если метод getName() возвращает String, то obj?.getName() будет иметь тип String?. Это гарантирует, что разработчик явно обработает возможность null на следующем этапе — например, через ?:, let, if (x != null), или приведение типа.

Оператор ?. может применяться к свойствам, методам, индексаторам (list?.get(0)), вызовам через вызываемые ссылки (callback?.invoke()), а также в составе цепочек с другими операторами — например, в связке с let:

val result = person?.address?.let { addr ->
"Город: ${addr.city}, индекс: ${addr.postcode}"
}

Здесь let выполнится только если person?.address не null, и внутри лямбды addr уже будет иметь ненулевой тип.

Важно отметить, что безопасный вызов не эквивалентен блоку if (obj != null) obj.method(). Он короче, выразительнее, и, что принципиально, не требует повторного введения переменной — тип внутри последующей обработки может быть автоматически уточнён (smart cast), но при этом сама цепочка остаётся компактной и не нарушает поток чтения кода.


Оператор нулевого ассерта (!!)

Оператор нулевого ассерта (!!) представляет собой последнее средство в арсенале null-безопасности Kotlin. Его использование означает явное указание компилятору: «Я утверждаю, что это значение не равно null. Если окажется, что я ошибся — выброси NullPointerException немедленно».

Синтаксис: выражение!!

Пример:

val name: String? = externalApi.getName()
val length = name!!.length // если name == null → NPE

На первый взгляд, !! противоречит философии Kotlin, направленной на устранение NullPointerException. Однако его существование оправдано практическими потребностями интеграции и работы с legacy-кодом. Например, при вызове Java-методов, аннотированных некорректно или вовсе без аннотаций @Nullable/@NotNull, Kotlin вынужден интерпретировать возвращаемые значения как nullable. Если разработчик обладает внешней гарантией того, что метод никогда не возвращает null (например, на основании документации или внутреннего знания реализации), !! позволяет ему «снять» nullable-обёртку и продолжить работу с ненулевым типом.

Тем не менее, использование !! считается признаком потенциальной уязвимости. Оно нарушает принцип fail-fast в пользу fail-explicitly: ошибка не скрывается, но и не предотвращается — она переносится на этап выполнения. Поэтому !! рекомендуется использовать только в исключительных случаях:

  • при интеграции с непроверенными Java API, когда другие способы (например, обёртка в requireNotNull() с диагностическим сообщением) нецелесообразны;
  • в тестовом коде, где null заведомо исключён условиями теста;
  • в крайних случаях оптимизации, когда проверка на null уже выполнена ранее, но компилятор не может этого доказать (например, при сложной логике, выходящей за рамки анализа smart cast).

Важно понимать, что name!! не просто «вытаскивает» значение из String? в String. Он проверяет значение во время выполнения: если name != null, выражение возвращает name как String; если name == null — выбрасывается kotlin.KotlinNullPointerException (подкласс стандартного NullPointerException с дополнительной диагностикой, включая имя переменной и номер строки). Это делает ошибку легко отслеживаемой, но не отменяет её фатальности для потока выполнения.


Семантическая иерархия операторов работы с null

В совокупности операторы ?., ?: и !! образуют стройную систему, отражающую различные стратегии управления отсутствующими значениями:

  • ?.избегание действия при null (пассивная стратегия, возвращает null);
  • ?:замена null на альтернативное значение (активная, но безопасная стратегия);
  • !!настаивание на отсутствии null (активная, но рискованная стратегия, делегирующая проверку времени выполнения).

Эта градация позволяет разработчику сознательно выбирать уровень гарантий и ответственности на каждом этапе работы с данными. При этом компилятор не позволяет смешивать nullable и non-nullable типы без явного участия одного из этих операторов (или эквивалентных конструкций, таких как let, also, run, requireNotNull, checkNotNull), что создаёт прочную основу для написания отказоустойчивого кода.


Операторы присваивания и составные операторы

Хотя в исходных тезисах они не упомянуты, для полноты картины стоит кратко затронуть операторы присваивания. В Kotlin, как и в других современных языках, поддерживаются составные операторы присваивания: +=, -=, *=, /=, %=. Их особенность — в том, что они, как и простые арифметические, транслируются в вызовы операторных функций: a += ba = a.plusAssign(b) (если avar) или a.plus(b) (если aval, но результат присваивается другой переменной). Это позволяет, например, использовать += для добавления элемента в изменяемый список (list += item), и это будет вызовом list.add(item).

Однако важно: в Kotlin отсутствует оператор ++ и -- как отдельные конструкции. Запись i++ возможна только если в типе i объявлены операторные функции inc() и dec(), и даже в этом случае i++ семантически эквивалентна i = i.inc(). Это исключает побочные эффекты, связанные с различием префиксного и постфиксного инкремента, и делает поведение полностью предсказуемым.


Операторы принадлежности: in и !in

Операторы in и !in предназначены для проверки вхождения значения в некоторую совокупность — будь то коллекция, множество, диапазон, строка или произвольный пользовательский тип. Выражение вида element in container читается естественно и интуитивно, что соответствует стремлению Kotlin к выразительности и приближению синтаксиса к естественному языку.

Семантически a in b транслируется компилятором в вызов b.contains(a). Это означает, что для поддержки оператора in у типа правого операнда должна быть доступна функция contains() с соответствующей сигнатурой. Стандартная библиотека Kotlin обеспечивает такую поддержку для всех основных контейнерных типов:

  • List<T>, Set<T>, Array<T> — проверка наличия элемента;
  • String — проверка вхождения подстроки или символа: "a" in "abc"true;
  • Map<K, V> — проверка наличия ключа: key in map эквивалентно map.containsKey(key);
  • Range<T> (например, IntRange, CharRange) — проверка попадания в интервал: 5 in 1..10true.

Оператор !in является логическим отрицанием in: a !in b транслируется в !b.contains(a). Использование !in предпочтительнее явного отрицания !(a in b), так как он короче, читабельнее и не требует скобок.

Важная особенность — ленивость и оптимизация. Для некоторых типов, например, Set, метод contains() выполняется за константное или логарифмическое время, в то время как для List — за линейное. Однако разработчику не нужно помнить эти детали: интерфейс contains() абстрагирует реализацию, и выбор структуры данных остаётся на этапе проектирования. Это позволяет писать высокоуровневый код, не жертвуя эффективностью.

Кроме того, оператор in интегрирован с механизмом smart cast. Если проверка x in listOf("A", "B", "C") выполнена в условии if, и x имеет тип String?, компилятор может сузить тип x до String внутри блока, если все элементы списка ненулевые — потому что contains() для List<String> никогда не вернёт true для null.

Перегрузка оператора in доступна любому классу: достаточно объявить функцию operator fun contains(element: T): Boolean. Это позволяет, например, создать класс «График работы», поддерживающий запись DayOfWeek.MONDAY in schedule, или «Географический регион», для которого Point(55.75, 37.62) in moscowArea будет проверять попадание координат в полигон.


Операторы диапазонов и прогрессий

Kotlin предоставляет встроенные средства для работы с упорядоченными последовательностями значений через понятия диапазона (range) и прогрессии (progression). Эти конструкции активно используются в циклах, проверках принадлежности и генерации последовательностей, но их синтаксис основан на операторах, а не на функциях.

Оператор .. — создание закрытого диапазона

Оператор a..b создаёт закрытый диапазон от a до b включительно. Он транслируется в вызов функции a.rangeTo(b), которая по умолчанию реализована для всех типов, реализующих Comparable<T>. Результатом является объект, реализующий интерфейс ClosedRange<T>.

Примеры:

val intRange = 1..5          // IntRange(1, 5) → 1, 2, 3, 4, 5
val charRange = 'a'..'z' // CharRange('a', 'z')
val dateRange = startDate..endDate // если LocalDate реализует Comparable

Диапазон, созданный через .., является неизменяемым и ленивым — он не хранит все элементы в памяти, а лишь границы и логику проверки принадлежности. Поэтому 1..1000000 занимает столько же памяти, сколько 1..2.

Функция until — создание открытого диапазона

Хотя until технически является функцией-расширением, а не оператором, она настолько тесно интегрирована в идиоматический Kotlin, что требует упоминания в этом разделе. Выражение a until b эквивалентно a..b-1 для целых чисел и создаёт диапазон, не включающий верхнюю границу. Это особенно удобно при работе с индексами:

for (i in 0 until list.size) { ... }  // i от 0 до size-1 включительно

Для нечисловых типов until может быть определён кастомно, но в стандартной библиотеке поддерживается только для целочисленных типов и Char.

Оператор ..< — экспериментальный открытый диапазон

Начиная с Kotlin 1.9, в экспериментальном режиме доступен оператор ..<, создающий полуоткрытый диапазон (включает левую границу, исключает правую). В будущем он может заменить until как более единообразное решение. Пример: 1..<5 → последовательность 1, 2, 3, 4.

Оператор шага step

Прогрессия — это диапазон с заданным шагом. Оператор step применяется к диапазону и возвращает объект типа *Progression (например, IntProgression):

val progression = 1..10 step 2  // 1, 3, 5, 7, 9
val backwards = 10 downTo 1 step 2 // 10, 8, 6, 4, 2

Также доступны функции downTo (убывающий диапазон) и reversed() (инверсия прогрессии).

Ключевой момент: диапазоны и прогрессии в Kotlin реализованы как значимые типы, а не как синтаксический сахар. Это позволяет передавать их как аргументы, возвращать из функций, сохранять в переменные и использовать в обобщённых алгоритмах. Например, функция может принимать ClosedRange<LocalDateTime> и работать с любым временным интервалом независимо от его длины.


Оператор индексации ([]) и оператор вызова (())

Kotlin унифицирует доступ к элементам по индексу и вызов «вызываемых» объектов через единый принцип — конвенции вызова.

Оператор индексации []

Запись obj[key] транслируется в obj.get(key), а присваивание obj[key] = value — в obj.set(key, value). Это позволяет любому классу поддерживать синтаксис, привычный для массивов и мап, просто реализовав соответствующие операторные функции.

Стандартные примеры:

  • list[0]list.get(0)
  • map["key"] = 42map.set("key", 42)
  • array[i]++array.set(i, array.get(i) + 1)

Важно: Kotlin не ограничивает количество параметров в get/set. Например, для двумерной матрицы допустима запись matrix[i, j], что соответствует matrix.get(i, j).

Этот подход особенно мощен при создании DSL или обёрток над сложными структурами. Например, можно определить класс Configuration, поддерживающий config["database.url"] как альтернативу config.getProperty("database.url"), улучшая читаемость конфигурационного кода.

Оператор вызова ()

Выражение obj() транслируется в obj.invoke(). Это позволяет любому объекту вести себя как функция. Особенно полезно это при работе с функциональными объектами, замыканиями и шаблонами проектирования, требующими гибкого поведения.

Примеры:

  • Переменная, ссылающаяся на лямбду: val f: () -> Int = { 42 }; f()42;
  • Класс, реализующий invoke:
    class Counter {
    var count = 0
    operator fun invoke() = ++count
    }
    val c = Counter()
    println(c()) // 1
    println(c()) // 2
  • Использование в DSL: html { body { p("text") } }, где html, body, p — функции, возвращающие объекты с invoke.

Оператор invoke может иметь произвольную сигнатуру: с параметрами, без, с возвращаемым значением или Unit. Это делает его универсальным инструментом для проектирования API, где поведение объекта зависит от контекста вызова.


Оператор as и as? — безопасное приведение типов

Хотя приведение типов формально относится к выражениям, а не к бинарным операторам, синтаксис as и as? настолько укоренился в повседневной практике, что требует включения в обзор.

  • expr as Typeнебезопасное приведение. Если expr не является экземпляром Type (или его подтипа), выбрасывается ClassCastException.
  • expr as? Typeбезопасное приведение. В случае несоответствия возвращается null, а результат всегда имеет nullable-тип (Type?).

Пример:

val obj: Any = "text"
val str: String? = obj as? String // "text"
val num: Int? = obj as? Int // null

Оператор as? органично сочетается с элвис-оператором и безопасным вызовом:

val length = (obj as? String)?.length ?: 0

Это выражение можно прочитать как: «Приведи obj к String, если получится — возьми длину, иначе используй ноль». Такая цепочка является идиоматичной для Kotlin и заменяет собой многострочные проверки через is и if.

Стоит подчеркнуть: приведение типов в Kotlin не изменяет сам объект — оно лишь изменяет видимость его интерфейса для компилятора. Это гарантирует отсутствие побочных эффектов и сохраняет ссылочную идентичность.